iT邦幫忙

2022 iThome 鐵人賽

DAY 13
0
Software Development

30天成為鍵盤麥可貝:前端視覺特效開發實戰系列 第 13

Day13: three.js 3D地球特效開發實戰:飛雷神之術走跳地球!—鏡頭追蹤與浮動文字

  • 分享至 

  • xImage
  •  

成品

Untitled

「飛雷神之術!」鏡頭跳轉的重要性

https://ithelp.ithome.com.tw/upload/images/20220928/20142505WEIAV2cpYj.jpg

圖片來源

上一篇我們實作城市的定位,但用戶仍必須手動轉動地球。手動轉動地球體驗不是很好,而這對簡報、展示來說是一大傷害。事實上,地球並不僅只是給客戶用而已。它除了當作畫面第一個登場的物件以外,公司業務拿畫面出來賣產品時,你所設計的畫面體驗也留給了客戶第一印象。客戶可能在第一時間就評分了整個產品。

所以說,地球不僅只是好用好看而已,對於商業價值有相當的影響。

為什麼要做地球?

回顧原因,它可以應用在很多場景上,例如:行銷網站、企業形象網站、活動網站、全球數位戰情室、航太科技、GIS畫面等等。這些對於前端視覺特效都非常重要。

製作地球也能讓我們釐清貼圖底層的運作模式,不僅討論到底層webGL、fragmentShader、vertexShader的渲染方式,也提到很多種貼圖。

本篇技術

  • 使用Vector3.lerp()轉動鏡頭位置
  • 使用normalize()mutiplyScalar() 鎖定鏡頭與地球的高度
  • 使用Math.cos()Math.pow() 控制鏡頭位移的軌道
  • 使用TextGeometry()建立浮動的3D文字物件

準備程式碼

上一篇的成果

Untitled (12).gif

CodePen

這裡也有codepen:

https://codepen.io/umas-sunavan/pen/NWMXYwZ

改善程式碼可讀性(非必要)

由於已經留下不少技術債。為了增加閱讀效率,我把部分的程式碼用函式包住:

const skydome = sreateSkydome()
const earth = createEarth()
const cloud = createCloud()
const ring = createRing()

這四個函式將一些變數留在函式作用域,並且減少全域的複雜度。

以下是整理過的程式碼,我們從這裡繼續。當然,如果沒有整理仍然可以繼續向下開發。

改善過後已準備好的程式碼

直接複製貼上就可以使用了。

import * as THREE from 'three';
import { OrbitControls } from 'https://unpkg.com/three@latest/examples/jsm/controls/OrbitControls.js';

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 10, 15)

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const sreateSkydome = () => {
	// 匯入材質
	// image source: https://www.deviantart.com/kirriaa/art/Free-star-sky-HDRI-spherical-map-719281328
	const skydomeTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/free_star_sky_hdri_spherical_map_by_kirriaa_dbw8p0w%20(1).jpg')
	// 帶入材質,設定內外面
	const skydomeMaterial = new THREE.MeshBasicMaterial({ map: skydomeTexture, side: THREE.DoubleSide })
	const skydomeGeometry = new THREE.SphereGeometry(100, 50, 50)
	const skydome = new THREE.Mesh(skydomeGeometry, skydomeMaterial);
	scene.add(skydome);
	return skydome
}

// 新增環境光
const addAmbientLight = () => {
	const light = new THREE.AmbientLight(0xffffff, 0.5)
	scene.add(light)
}

// 新增點光
const addPointLight = () => {
	const pointLight = new THREE.PointLight(0xffffff, 1)
	scene.add(pointLight);
	pointLight.position.set(10, 10, -10)
	pointLight.castShadow = true
	// 新增Helper
	const lightHelper = new THREE.PointLightHelper(pointLight, 5, 0xffff00)
	// scene.add(lightHelper);
	// 更新Helper
	lightHelper.update();
}

// 新增平行光
const addDirectionalLight = () => {
	const directionalLight = new THREE.DirectionalLight(0xffffff, 1)
	directionalLight.position.set(0, 0, 10)
	scene.add(directionalLight);
	directionalLight.castShadow = true
	const d = 10;

	directionalLight.shadow.camera.left = - d;
	directionalLight.shadow.camera.right = d;
	directionalLight.shadow.camera.top = d;
	directionalLight.shadow.camera.bottom = - d;

	// 新增Helper
	const lightHelper = new THREE.DirectionalLightHelper(directionalLight, 5, 0xffff00)
	// scene.add(lightHelper);
	// 更新位置
	directionalLight.target.position.set(0, 0, 0);
	directionalLight.target.updateMatrixWorld();
	// 更新Helper
	lightHelper.update();
}

addPointLight()
addAmbientLight()
addDirectionalLight()

const createEarth = () => {
	const earthGeometry = new THREE.SphereGeometry(5, 600, 600)
	const earthTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/8081_earthmap2k.jpg')
	// 灰階高度貼圖
	const displacementTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/editedBump.jpg')
	const roughtnessTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/8081_earthspec2kReversedLighten.png')
	const speculatMapTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/8081_earthspec2k.jpg')
	const bumpTexture = new THREE.TextureLoader().load('https://storage.googleapis.com/umas_public_assets/michaelBay/day10/8081_earthbump2k.jpg')
	
	
	const earthMaterial = new THREE.MeshStandardMaterial({
		map: earthTexture,
		side: THREE.DoubleSide,
		roughnessMap: roughtnessTexture,
		roughness: 0.9,
		// 將貼圖貼到材質參數中
		metalnessMap: speculatMapTexture,
		metalness: 1,
		displacementMap: displacementTexture,
		displacementScale: 0.5,
		bumpMap: bumpTexture,
		bumpScale: 0.1,
	})
	const earth = new THREE.Mesh(earthGeometry, earthMaterial);
	scene.add(earth);
	return earth
}

const createCloud = () => {
	const cloudGeometry = new THREE.SphereGeometry(5.4, 60, 60)
	// 匯入材質
	// texture source: http://planetpixelemporium.com/earth8081.html
	const cloudTransparency = new THREE.TextureLoader().load('8081_earthhiresclouds4K.jpg')
	// 帶入材質,設定內外面
	const cloudMaterial = new THREE.MeshStandardMaterial({
		transparent: true,
		opacity: 1,
		alphaMap: cloudTransparency
	})
	const cloud = new THREE.Mesh(cloudGeometry, cloudMaterial);
	scene.add(cloud);
	return cloud
}

const createRing = () => {
	const geo = new THREE.RingGeometry( 0.1, 0.13, 32 );
	const mat = new THREE.MeshBasicMaterial( { color: 0xffff00, side: THREE.DoubleSide } );
	const ring = new THREE.Mesh( geo, mat );
	scene.add( ring );
	return ring
}

const control = new OrbitControls(camera, renderer.domElement);

const cities = [
	{ name: "--- select city ---", id: 0, lat: 0, lon: 0, country: "None" },
	{ name: "Mumbai", id: 1356226629, lat: 19.0758, lon: 72.8775, country: "India" },
	{ name: "Moscow", id: 1643318494, lat: 55.7558, lon: 37.6178, country: "Russia" },
	{ name: "Xiamen", id: 1156212809, lat: 24.4797, lon: 118.0819, country: "China" },
	{ name: "Phnom Penh", id: 1116260534, lat: 11.5696, lon: 104.9210, country: "Cambodia" },
	{ name: "Chicago", id: 1840000494, lat: 41.8373, lon: -87.6862, country: "United States" },
	{ name: "Bridgeport", id: 1840004836, lat: 41.1918, lon: -73.1953, country: "United States" },
	{ name: "Mexico City", id: 1484247881, lat:19.4333, lon: -99.1333 , country: "Mexico" },
	{ name: "Karachi", id: 1586129469, lat:24.8600, lon: 67.0100 , country: "Pakistan" },
	{ name: "London", id: 1826645935, lat:51.5072, lon: -0.1275 , country: "United Kingdom" },
	{ name: "Boston", id: 1840000455, lat:42.3188, lon: -71.0846 , country: "United States" },
	{ name: "Taichung", id: 1158689622, lat:24.1500, lon: 120.6667 , country: "Taiwan" },
]

let lerpTarget
let lerpPropical = new THREE.Vector3(0,0,0)
let tropical

const citySelect = document.getElementsByClassName('citySelect')[0]
citySelect.innerHTML = cities.map( city => `<option value="${city.id}">${city.name}</option>`)
citySelect.addEventListener( 'change', (event) => {
	const cityId = event.target.value
	const seletedCity = cities.find(city => city.id+'' === cityId)
	const cityEciPosition = lonLauToRadian(seletedCity.lon, seletedCity.lat, 4.4)
	ring.position.set(cityEciPosition.x, -cityEciPosition.z, -cityEciPosition.y)
	const center = new THREE.Vector3(0,0,0)
	ring.lookAt(center)
	tropical = 1
	lerpTarget = new THREE.Vector3(0,0,0).set(...ring.position.toArray()).multiplyScalar(3)
	lerpPropical.set(...camera.position.toArray())
	// camera.position.set(...ring.position.toArray()).multiplyScalar(3)
	control.update()
})

const skydome = sreateSkydome()
const earth = createEarth()
const cloud = createCloud()
const ring = createRing()

function animate() {
	requestAnimationFrame(animate);
	renderer.render(scene, camera);
	cloud.rotation.y += 0.0005
	skydome.rotation.y += 0.001
	if (lerpTarget) {
		lerpPropical.lerp(lerpTarget, 0.05).normalize().multiplyScalar(20)
		let value = Math.pow(tropical*2-1, 4.)
		camera.position.set(lerpPropical.x, lerpPropical.y*(value), lerpPropical.z).normalize().multiplyScalar(20)
		control.update()
	}
	tropical*=0.97
}
animate();

// 經緯度轉換成弧度
const lonLauToRadian = (lon, lat, rad) => llaToEcef(Math.PI * (0 - lat) / 180, Math.PI * (lon / 180), 1, rad)
// 城市弧度轉換成世界座標
const llaToEcef = (lat, lon, alt, rad) => {
	let f = 0
	let ls = Math.atan((1 - f) ** 2 * Math.tan(lat))
	let x = rad * Math.cos(ls) * Math.cos(lon) + alt * Math.cos(lat) * Math.cos(lon)
	let y = rad * Math.cos(ls) * Math.sin(lon) + alt * Math.cos(lat) * Math.sin(lon)
	let z = rad * Math.sin(ls) + alt * Math.sin(lat)
	return new THREE.Vector3(x, y, z)
}

開發功能:飛雷神之術!—鏡頭追蹤

設定鏡頭定位在城市位置正上方的外太空即可。

如何取得城市位置正上方的外太空位置?將城市position向量放大三倍即可。(透過multiplyScalar(),就在之前的必備的向量函式時都有提及)

// 用戶更新下拉選單後的回呼
citySelect.addEventListener( 'change', (event) => {
	...
	// 修改鏡頭位置,multiplyScalar可以縮放向量
	camera.position.set(...ring.position.toArray()).multiplyScalar(3)
	// 由OrbitControl幫我們更新鏡頭角度
	control.update()
})

Untitled

可以看到用戶已經可以切換位置了

開發功能:鏡頭移動

開發功能:鏡頭位移動畫

在討論必備的向量函式時,有提到lerp。現在它派上用場了。如果一個向量使用它,得提供兩個參數:結果參數跟alpha參數,向量將移動alpha倍的距離向結果參數(另一個向量)移動。

所以比方說,我要讓向量(0,0,0)向結果參數(10,10,0)移動0.5倍距離(alpha參數),則結果就是(5,5,0)。再執行一次就變成(7.5, 7.5, 0),再一次就是(0.875, 0.875, 0),以此類推。

把這個函式放在animate裡面,就可以做出像是Ease-out的動畫效果,連動畫套件都不用裝,讚。

https://ithelp.ithome.com.tw/upload/images/20220928/20142505qQPt6cEskX.png

// 作為lerp移動的結果參數
let lerpTarget
citySelect.addEventListener( 'change', (event) => {
	...
	// 當用戶選城市時,更新lerp移動的結果參數
	lerpTarget = new THREE.Vector3(0,0,0)
		// 設定移動結果位置為圖釘位置
		.set(...ring.position.toArray())
		// 乘以三倍,使得位置位在城市正上方的外太空
		.multiplyScalar(3)
	// 不在此直接設定鏡頭位置了
	camera.position.set(...ring.position.toArray()).multiplyScalar(3)
	control.update()
})

接著,我們在animate()加上函式,使得鏡頭不斷的位移,實現動畫:

function animate() {
	...
	// 用戶有選取城市才會執行下面
	if (lerpTarget) {
		// 鏡頭位置向城市上方的外太空移動
		camera.position.lerp(lerpTarget, 0.05)
		// 使得OrbitControl不斷幫我們更新鏡頭
		control.update()
	}
}

接著就能做出效果:

Untitled

畫面看起來非常好,但事情還沒有結束。

開發功能:曲線移動鏡頭

你玩一玩會發現,怎麼怪怪的?

Untitled

你會發現,鏡頭移動的路徑是直線的。

https://ithelp.ithome.com.tw/upload/images/20220928/20142505t8wP3XTqC9.png

我們需要把路徑改成曲線。

https://ithelp.ithome.com.tw/upload/images/20220928/20142505wZvO1MYEEE.png

為什麼會產生這個問題?

鏡頭在位移時,其位置距離地球的遠近不一樣。當鏡頭在起點跟終點時,鏡頭距離地球的距離一致,然而在位移的過程中,其距離地球過近。
而解決方法,就是在鏡頭移動的過程中,持續固定鏡頭對地球的距離。固定距離的方法有很多種,而我的作法有三個步驟:

  1. 先記住鏡頭一開始位於地球中心(也就是座標中心)的向量有多長。
  2. 將向量改成一單位
  3. 再縮放向量到的距離

我們看圖理解:

  • 步驟一:一開始離世界中心距離假設為20。(世界中心也是地球中心)

    https://ithelp.ithome.com.tw/upload/images/20220928/201425056Aae87p6c8.png

  • 步驟二:現在將該向量縮小到長度為1,但方向不變。這個可以透過Vector3.normalize()完成

    https://ithelp.ithome.com.tw/upload/images/20220928/20142505ympxZYrJXv.png

  • 步驟三:一開始我們知道長度為20,所以只要再乘上20即可。

    https://ithelp.ithome.com.tw/upload/images/20220928/20142505nzC6QTjOvI.png

  • 如果每一幀鏡頭移動時都做同樣的事情,那麼就可以形成一個完美的弧度

    https://ithelp.ithome.com.tw/upload/images/20220928/20142505G377QQeCOq.png

Vector3.normalize()可以把向量轉成單位向量,以此固定距離成一單位;Vector3.multiplyScalar()則能夠縮放向量到正確的距離。有這兩個函式,就可以開始把邏輯寫在程式了。

這兩個都在我們介紹必備的向量函式時都有提及,可以參考:Day7: three.js的一方通行:矢量操作——全面釐清向量與底層特性

套用在我們專案,就是:

- camera.position.lerp(lerpTarget, 0.1)
+ // 固定長度為一單位,然後放大長度
+ camera.position.lerp(lerpTarget, 0.1).normalize().multiplyScalar(15)

結果就自然多了:

Untitled

開發功能:OrbitControl自動轉正的特性以及解法

當鏡頭靠近北極時,會有奇怪的旋轉。

Untitled

為什麼會有奇怪的旋轉?

這跟OrbitControl的特性有關。OrbitControl 一個很大的特性在Day6: three.js 圓弧的藝術家!弧線的教授!——軌道控制器有提到:當用戶把鏡頭繞過最頂端之後,OrbitControls會自動校正頭頂方向。

https://ithelp.ithome.com.tw/upload/images/20220928/20142505IH5vi0Jzb5.png

也是因為這個特性,讓鏡頭在繞過北極的時候,有不自然的旋轉。

為了解決這個方法,我加上了一個函式,來讓鏡頭沿著赤道旋轉,避免這個問題。

開發功能:沿著赤道位移鏡頭

因為鏡頭往北極時都要轉正,為了避免這個問題,我改從赤道旋轉鏡頭。但要如何開發呢?

目前為止,我們有lerpTarget,它每一幀都移動一個距離,並讓鏡頭綁定給它lerpTarget

https://ithelp.ithome.com.tw/upload/images/20220928/20142505Ih8GX2y541.png

現在,我們多出一個變數,先頂替camera的路徑移動,我們稱這個變數作moveAlongTropical好了。我們接著修改moveAlongTropical 的數值,使得它沿著赤道移動,再綁定移動軌跡給鏡頭。

https://ithelp.ithome.com.tw/upload/images/20220928/20142505kCib7qEvpe.png

要如何修改moveAlongTropical 的數值來讓鏡頭沿著赤道移動?從下圖可見紅字,假設紅字代表是一個變數,它將lerpTargt的Y軸數值乘成比較小的數值,就可以改變鏡頭的位置了。

https://ithelp.ithome.com.tw/upload/images/20220928/201425057x3NExPvdg.png

如圖中紅字那樣,只要lerpTarget座標中的高度Y乘上一個變數,即可將鏡頭偏向赤道移動。我將該變數命名為moveVolume,待會解釋。

在程式碼實作是這樣:

let lerpTarget
// 加上兩個變數
let moveAlongTropical = new THREE.Vector3(0,0,0)
// moveAlongTropical的移動進度
let moveProgress

在此我們宣告兩個變數。當用戶點選新的城市,設moveProgress為1,將由1走到0,做為moveAlongTropical移動的進度。

citySelect.addEventListener( 'change', (event) => {
	...
	// 給定moveAlongTropical於移動的起點
	moveAlongTropical.set(...camera.position.toArray())
	// pregress將由1走到0,控制稍候的變數「moveVolume」以做變化
	moveProgress = 1
})

每一幀,moveAlongTropical都會頂替鏡頭原先的位置。moveVolume使得鏡頭的Y座標保持在赤道。

moveVolume為什麼能使鏡頭在赤道?因為moveVolume範圍是1~0,它乘給了Y,導致Y值減少了,也因此使得鏡頭靠近赤道。

https://ithelp.ithome.com.tw/upload/images/20220928/20142505Y6ZViq6ykd.png

function animate() {
	...
	// 建立一個函式,使得鏡頭的航向可以往赤道移動
	let moveVolume = Math.pow(moveProgress*2-1, 4.)
	// 用戶有選取城市才會執行下面
	if (lerpTarget) {
		// 綁定數值給moveAlongTropical
		moveAlongTropical.lerp(lerpTarget, 0.05).normalize().multiplyScalar(15)
		// 現在,將camera位置綁定到moveAlongTropical上。其中由於moveVolume範圍是1~0,其減少了Y值的輸出
		camera.position.set(moveAlongTropical.x, moveAlongTropical.y*moveVolume, moveAlongTropical.z).normalize().multiplyScalar(20)
		// 使得OrbitControl不斷幫我們更新鏡頭
		control.update()
	}
	// 不斷更新progress,使得moveVolume不斷更新數值
	moveProgress*=0.97
}

如此一來,地球就可以沿著赤道移動,解決鏡頭繞過北極時的問題了。

Untitled

開發功能:浮動文字開發

有兩種開發方式,一種是將文字當作一個Mesh,一種是將文字當作是一個html DOM,這兩種都可以。前者提供多元的文字渲染,後者提供用戶複製文字並作後續操作。我介紹前者為主。

浮動文字開發:建立文字Mesh

首先加上一個函式以新增文字Mesh。身為一個Mesh,它就跟前幾篇介紹的物件一樣,需要形狀(geometry)跟材質(material),文字可用TextGeometry。只是textGeometry的參數較多。儘管如此,這些參數仍然很好理解。

const addText = text => {
	const textGeometry = new TextGeometry( text, {
		font: font,
		size: 0.2,
		height: 0.01,
		curveSegments: 2,
		bevelEnabled: false,
		bevelThickness: 10,
		bevelSize: 0,
		bevelOffset: 0,
		bevelSegments: 1
	} );
	const textMaterial = new THREE.MeshBasicMaterial({color: 0xffff00})
	const textMesh = new THREE.Mesh(textGeometry, textMaterial)
	scene.add(textMesh)
	return textMesh
}
// 初始化物件
let text = addText('')

接著,每當用戶選擇城市時,就更新文字Mesh,如下:

citySelect.addEventListener( 'change', (event) => {
	// 移除上一個城市的文字mesh
	text.removeFromParent()
	// 新增文字mesh
	text = addText(seletedCity.name)
	// 設定文字位置於圖釘上
	text.position.set(ring.position.toArray())
})

浮動文字開發:使文字Mesh面朝鏡頭

你會看到文字的方向怪怪的,這是因為它面向的方向並不是鏡頭。

Untitled

我們只要文字面向鏡頭即可。

function animate() {
	...
	text.lookAt(...camera.position.toArray())
}

浮動文字開發:文字Mesh不重疊圖釘

修完之後,我們由圖可見文字擋到圖釘了。

Untitled

這個時候只要應用我們在Day3: Three.js空間座標!讓世界繞著我旋轉!討論到的translate()即可。我更新了文字的位置,使得文字不會遮住圖釘:

// 0.2是我們在建立TextGeometry時的文字寬度
textMesh.geometry.translate(text.length*-0.2,0.2,0)

成品

Untitled

CodePen

https://ithelp.ithome.com.tw/upload/images/20220928/201425051VhYsC1phY.png

https://codepen.io/umas-sunavan/pen/RwyQGZm

以上就是透過3D地球特效開發所做的成果。歷經三篇實戰跟一篇原理,我們已經吸收了非常非常多東西,而且多數的技術,都從過去文章介紹的原理疊加而來。

這四篇的總整理

經過這四篇,我們學到的東西包含:

  • 貼圖在底層的原理
  • WebGL取樣貼圖的過程
  • 最常用的七種貼圖
  • 應用其中六種貼圖實作地球
  • 製作地球的雲層
  • 經緯度座標轉成世界座標
  • 由經緯度標記大城市位置
  • 座標轉換的數學原理與推導
  • 更新鏡頭位置於城市正上方
  • 使用Lerp打造位置移動的Ease效果
  • 使用特定函式修改鏡頭位移的路徑
  • 新增城市文字於地球上
  • 設定文字面向鏡頭

地球的潛力

事實上,我們只做到冰山一角,地球能做的功能真的太多了,沒辦法在預計的篇幅中介紹完。地球能做到的包含:

  1. 應用衛星圖資,將街道、衛星影像、交通等圖磚放到地球上面,並且依照鏡頭縮放更換合適解析度的圖磚
  2. 使用HTML DOM元件製作文字,而非使用Mesh製作文字(Vector3.project/ Vector3.unproject
  3. 在各大城市或國家加上一個圓柱。圓柱高度代表各國差異,變成一種資料視覺化。(CylinderGeometry
  4. 加上飛機模型,模擬飛機或是船從A地飛到B地的畫面,以提供貨運資訊(Vector3.Lerp, Curve
  5. 隨機產生地形圖,自製星球

諸如此類,這些都能由你發揮。如果有興趣,都可以複製我Codepen的程式碼來玩!

下篇

接下來我將以3D圓餅圖為示例,從中介紹線段、曲線、轉成Mesh、製作3D,其過程也將相當有趣。


上一篇
Day12: three.js 3D地球特效開發實戰:留下飛雷神術式吧!—經緯度座標轉換
下一篇
Day14: three.js 3D圖表特效開發實戰:繪畫就跟佐為下棋一樣簡單:線段原理
系列文
30天成為鍵盤麥可貝:前端視覺特效開發實戰31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言